fix(collection): allow writes and reads to proceed during Optimize - #614
Conversation
Optimize() held schema_handle_mtx_ exclusively for its whole duration, so Insert/Query/Fetch/Delete (which take it in shared mode) were blocked until the long-running compact finished. - Downgrade Optimize's schema lock to shared mode; writes and reads now proceed during the compact and only wait on the short write_mtx_ critical sections (flush and version commit). Schema operations and close/destroy still take the lock exclusively and remain mutually exclusive with Optimize. - Add optimize_mtx_ to keep concurrent Optimize calls serialized, preserving the previous queuing semantics. - Make SegmentManager internally thread-safe with a shared_mutex: readers (get_segments) can now run concurrently with the segment replacement in Optimize's commit phase. - Add a regression test that asserts inserts and fetches make progress while a background Optimize is running. Fixes alibaba#553
egolearner
left a comment
There was a problem hiding this comment.
基本ok,ut中验证optimize和写、fetch、query并发会更加完备
a478c5c to
63d26e0
Compare
Strengthening the UT to run Insert, Fetch and Query concurrently with a background Optimize (review feedback) exposed a use-after-free: the commit phase called reload_vector_index() on a live segment, destroying vector indexers and mutating block metas that concurrent readers were still using (readers only hold the shared schema lock). - Commit the new segment set by reopening segments from their new metas and swapping instances, never mutating live segments in place; in-flight readers keep replaced instances alive via shared_ptr. - Add SegmentManager::replace_segments() to apply the whole swap under one lock (validate first, then mutate), so readers never observe a partially committed segment set (no transient duplicate or missing segments), and drop the now-unused destroy_segment(). - Extend the regression test: writer, fetcher and querier threads now run concurrently with Optimize; worker failures are collected and asserted on the main thread. Query errors caused by the pre-existing Insert/Query visibility race in the writing segment (reproducible without Optimize) are tolerated and documented; a quiescent full-topk query is asserted after all threads join.
With Optimize committing via segment reopen-and-swap, the pre-index vector files of a replaced segment instance (e.g. the flat file that a newly built HNSW index supersedes) were no longer deleted and lingered on disk until the segment was compacted away. Reclaim them with the same mark-and-defer model segments already use: - VectorColumnIndexer: add MarkDestroyOnRelease(); a marked indexer closes and removes its index file in the destructor, i.e. only after the last reference is released, so in-flight readers of the replaced segment instance keep the file alive until they finish. - Segment: add mark_vector_index_files_for_removal(). Base and quantized indexers are marked independently: when a quantized index is built on top of a reused base file (see create_vector_index), only the old quantized file is superseded and the base file is still referenced by the new segment meta, so it must not be marked. - Optimize: after the new segment set is committed, mark the replaced instances' superseded indexers. - Add a regression test asserting exactly one index file remains per indexed column after Optimize and that a fresh open reads all docs.
|
egolearner
left a comment
There was a problem hiding this comment.
建议:使用 maintenance_mtx_ 简化 Optimize 并发方案
当前方案通过让 Optimize() 全程持有 schema_handle_mtx_ 的 shared lock,使 Query/Insert 可以在 compact 阶段继续执行。方向是对的,但它带来了一个新的问题:Optimize 提交时不能再原地调用 reload_vector_index(),因为并发 Query 可能仍在使用同一个 live Segment。
为解决这个问题,当前 PR 又引入了:
- reopen 新 Segment 并按 ID 替换旧实例;
SegmentManager::replace_segments();mark_vector_index_files_for_removal();VectorColumnIndexer::MarkDestroyOnRelease();- 基于
shared_ptr生命周期的延迟文件删除。
这套机制修改范围较大,Segment 发布和文件回收逻辑分散在 Collection、SegmentManager、Segment 和 VectorColumnIndexer 多层。另外,当前实现仍然在 write_mtx_ 内执行 Segment::Open(),并且 manifest 已经持久化后才 reopen;Open 失败时可能造成磁盘 version 与内存 SegmentManager 状态不一致。
这里可以考虑一个更小的方案:新增 maintenance_mtx_,将 Optimize 拆成“独占切换、锁外构建、独占提交”三个阶段。
1. maintenance_mtx_ 的职责
mutable std::mutex maintenance_mtx_;这把锁只负责串行化会修改 schema、persisted segment 结构或者 collection 生命周期的维护操作:
OptimizeCreateIndex/DropIndexAddColumn/AlterColumn/DropColumnClose/Destroy- 另一个并发
Optimize
Query、Fetch、Insert、Update、Delete 不获取 maintenance_mtx_。
Flush 可以有两种选择:
- 为完全保持 main 的串行语义,也获取
maintenance_mtx_; - 如果确认它只 flush 当前 writing segment,不切换 segment、不修改 version,则可以只保留 schema 独占锁,允许它在 Optimize compact 阶段执行。
所有维护操作必须遵守统一的锁顺序:
maintenance_mtx_
-> schema_handle_mtx_
-> write_mtx_
-> SegmentManager::mutex_
尤其不能先拿 schema 独占锁再等待 maintenance_mtx_,否则一个等待 Optimize 的 CreateIndex 会长期占住 schema 锁,重新阻塞 Query/Insert。
2. Optimize 的三个阶段
Status CollectionImpl::Optimize(const OptimizeOptions &options) {
CHECK_COLLECTION_READONLY_RETURN_STATUS;
std::lock_guard maintenance_lock(maintenance_mtx_);
std::vector<Segment::Ptr> persist_segments;
// Phase 1: safely seal the current writing segment.
{
std::unique_lock schema_lock(schema_handle_mtx_);
std::lock_guard write_lock(write_mtx_);
CHECK_DESTROY_RETURN_STATUS(destroyed_, false);
if (writing_segment_->has_record()) {
auto s = switch_to_new_segment_for_writing();
CHECK_RETURN_STATUS(s);
}
persist_segments = get_all_persist_segments();
}
if (persist_segments.empty()) {
return Status::OK();
}
// Phase 2: expensive compact/index build without schema/write lock.
auto delete_store_clone = delete_store_->clone();
auto tasks =
build_compact_task(schema_, persist_segments, options.concurrency_,
delete_store_clone->make_filter());
auto s = execute_compact_task(tasks);
CHECK_RETURN_STATUS(s);
// Phase 3: short exclusive commit.
{
std::unique_lock schema_lock(schema_handle_mtx_);
std::lock_guard write_lock(write_mtx_);
s = commit_optimize_tasks(tasks);
CHECK_RETURN_STATUS(s);
}
return Status::OK();
}Phase 2 不需要持有 schema_handle_mtx_:
maintenance_mtx_保证 schema DDL、Close、Destroy 不会在 compact 期间执行;- Optimize 持有输入 Segment 的
shared_ptr; - Insert 只能增加新的 persisted segment,不会修改 Optimize 已经捕获的 persisted segment;
- commit 阶段重新读取最新 Version,并只移除本次 compact 的输入 Segment,因此 Optimize 期间新产生的 Segment 会被保留。
3. 为什么可以恢复 reload_vector_index()
Phase 3 获取 schema_handle_mtx_ 独占锁后:
- 已经运行的 Query/Fetch 必须先结束并释放 shared lock;
- 新 Query/Fetch 无法进入;
- Insert/Delete 同样无法进入;
- 没有 reader 正在使用旧 Segment 的 vector indexer。
因此可以安全复用 main 原来的提交逻辑:
create_index_task.input_segment_->reload_vector_index(
*schema_, create_index_task.output_segment_meta_,
create_index_task.output_vector_indexers_,
create_index_task.output_quant_vector_indexers_);这样可以删除:
- 同 ID Segment reopen-and-swap;
SegmentManager::replace_segments()的特殊替换语义;mark_vector_index_files_for_removal();VectorColumnIndexer::MarkDestroyOnRelease();- indexer 析构时的延迟文件删除。
旧索引文件可以在独占 commit 阶段立即关闭和删除,因为所有旧 reader 已经退出。
4. CreateIndex 的修改示例
CreateIndex 本身不需要拆阶段,只需在 schema 锁之前获取 maintenance lock:
Status CollectionImpl::CreateIndex(
const std::string &column_name,
const IndexParams::Ptr &index_params,
const CreateIndexOptions &options) {
CHECK_COLLECTION_READONLY_RETURN_STATUS;
std::lock_guard maintenance_lock(maintenance_mtx_);
std::unique_lock schema_lock(schema_handle_mtx_);
CHECK_DESTROY_RETURN_STATUS(destroyed_, false);
// Existing CreateIndex implementation remains unchanged.
...
}假设 Optimize 正在 compact:
- Optimize 持有
maintenance_mtx_,但 Phase 2 不持 schema 锁; - CreateIndex 阻塞在
maintenance_mtx_,不会提前占用 schema 独占锁; - Query/Insert 仍然可以正常获得 schema shared lock;
- Optimize 进入 Phase 3,短暂获取 schema 独占锁完成提交;
- Optimize 释放
maintenance_mtx_后,CreateIndex 才开始执行。
这样可以保证 CreateIndex 不会基于一份正在被 Optimize 重写的 Segment set 修改 schema/index,也不会因为等待 Optimize 而间接阻塞正常 Query/Insert。
5. SegmentManager 仍需保留 shared mutex
SegmentManager 的内部 shared_mutex 仍然需要保留。
Optimize Phase 2 期间:
- Query 可能调用
get_segments(); - Insert 可能切换 writing segment 并调用
add_segment()。
因此 map 的读写仍需分别使用 shared/unique lock。但 commit 阶段已经持有 schema 独占锁,不再需要复杂的同 ID原子 replacement,可以恢复简单的 add_segment() / destroy_segment()。
6. 并发语义
采用该方案后:
- Optimize 与 DDL、Close、Destroy:仍然串行;
- 两个 Optimize:仍然串行;
- Optimize Phase 1/3 与 Query/Insert:短暂串行;
- Optimize Phase 2 与 Query/Fetch/Insert/Delete:可以并发;
- CreateIndex 与 Query/Insert:行为保持 main 不变,仍然串行;
- CreateIndex 等待 Optimize 时不会持有 schema 锁,因此不会阻塞 Query/Insert。
该方案只放开 issue 要求的“Optimize 耗时 compact/rebuild 阶段与普通读写并发”,不扩展到并发 DDL。
7. 已知边界
这个方案不解决两个已有问题:
switch_to_new_segment_for_writing()仍在 Phase 1 执行dump(),如果 dump 较慢,第一次停顿仍可能偏长。彻底解决需要 sealed segment,将 writing segment 切换和 dump 解耦。VersionManager::apply() + flush()的失败原子性仍然不够完整,可以后续独立改成 durablecommit(version)。
整体上,这个方案保留 main 已有的独占提交模型,只将耗时 compact 阶段移出 schema/write 锁,修改范围更小,并且不需要引入新的 Segment 和索引文件生命周期协议。
…view feedback Per review feedback on the shared-lock model: a maintenance operation waiting for a running Optimize became a pending exclusive acquirer of the schema lock, blocking new readers on typical shared_mutex implementations. Changes: - Replace optimize_mtx_ with maintenance_mtx_ (acquired by Optimize, schema DDLs, Flush, Close and Destroy before the schema lock). Lock order: maintenance -> schema -> write -> SegmentManager. - Restructure Optimize into three phases: exclusive seal, lock-free compact (MoveDirectory + Segment::Open moved to lock-free phase 2 tail, so an open failure aborts before the manifest is persisted), and short exclusive commit that restores in-place reload_vector_index with eager file removal (no reader can hold indexers under exclusive schema lock). - Downgrade read-only Stats()/Schema()/Options() to shared locking. - Remove replace_segments() and the deferred index-file removal machinery introduced earlier in this PR; they are only needed by lock-free consumers (DocIterator, alibaba#597) and will land there. - Keep and adapt the two regression UTs (concurrent read-write and superseded index file count).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
tests/db/collection_test.cc:3039
- The test currently ignores all
Query()errors, which can mask real regressions introduced by the new Optimize concurrency (e.g., schema/version/segment visibility issues). To keep the regression signal strong, only tolerate the specific expected transient failure (ideally by checking status code/type or a known message substring) and record any other error viarecord_error().
auto result = collection->Query(query);
if (!result.has_value()) {
// Tolerated: a doc admitted by the writing segment's streaming
// vector index may not yet be visible in its forward store,
// transiently failing the query. Pre-existing Insert/Query race,
// unrelated to Optimize.
continue;
}
tests/db/collection_test.cc:2944
- Using a fixed
sleep_for(200ms)to 'ensure Optimize is running' is prone to flakiness across different machines and build types (Optimize could finish before workers start, or not reach the intended phase). A more deterministic approach would be to add an explicit synchronization point (e.g., a test hook/failpoint or a latch signaled when Optimize enters the compact phase) so the worker threads reliably exercise the intended concurrency window.
// give the optimizer time to take its locks and start compacting
std::this_thread::sleep_for(std::chrono::milliseconds(200));
src/db/index/segment/segment_manager.cc:171
segment_idis not used in this loop. If the project builds with warnings-as-errors, this structured binding can trigger an unused-variable warning on some toolchains. Consider iterating asfor (auto &pair : segments_map_)and pushingpair.second, or otherwise avoid binding the unused key.
for (auto &[segment_id, segment] : segments_map_) {
segments.push_back(segment);
}
tests/db/collection_test.cc:3031
- This uses a C-style cast to convert raw float bytes into a
std::string. Preferreinterpret_cast<const char*>here to avoid dropping constness and to make the intent explicit; it also helps prevent compiler warnings in stricter builds.
query.target_.set_vector(
std::string((char *)vector.value().data(),
vector.value().size() * sizeof(float)));
Restore Stats()/Schema()/Options() to std::lock_guard.
…ilot review - Tolerate only the known transient Query failure (error message containing 'fetch table failed', a pre-existing writing-segment Insert/Query visibility race); any other error is now recorded as a failure instead of being silently ignored. - Replace C-style casts with reinterpret_cast<const char*> in the new test code (const-correctness). - Drop the unused structured binding in SegmentManager::drop_column (avoids warnings under warnings-as-errors).
…ncy UT Per Copilot review: - Only spot-check docs beyond initial_doc_count when the writer actually made progress during Optimize; a no-insert run (e.g. a very fast Optimize) previously caused a false failure fetching a doc that was never inserted. - Yield in the fetch/query worker loops to reduce CPU load under CI contention without affecting progress measurement.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
tests/db/collection_test.cc:2944
- A fixed 200ms sleep makes the test timing-dependent and potentially flaky on slow/fast machines (Optimize may not have started yet, or may have already finished). Prefer a deterministic synchronization point (e.g., a hook/latch indicating compaction has entered phase 2), or at least poll with a bounded wait for an observable state that implies Optimize is running before starting progress assertions.
// give the optimizer time to take its locks and start compacting
std::this_thread::sleep_for(std::chrono::milliseconds(200));
src/db/collection.cc:866
- If
Segment::Open()fails afterMoveDirectory()succeeds,Optimize()returns early leaving an orphaned segment directory at its final path that is not referenced by the manifest/version. Consider adding rollback cleanup (e.g., delete/move-backnew_segment_path) on failure paths after the move, and similarly consider cleanup if the later version apply/flush fails.
if (!FileHelper::MoveDirectory(tmp_segment_path, new_segment_path)) {
return Status::InternalError("move segment directory failed");
}
compact_task.output_segment_meta_->set_id(new_segment_id);
auto new_segment =
Segment::Open(path_, *schema_, *compact_task.output_segment_meta_,
id_map_, delete_store_, version_manager_,
SegmentOptions{true, options_.enable_mmap_});
if (!new_segment.has_value()) {
return new_segment.error();
}
src/db/index/segment/segment_manager.cc:31
std::unique_lockis defined in<mutex>. Relying on indirect includes via<shared_mutex>is not guaranteed to be portable across standard library implementations. Ensure<mutex>is included in the relevant header/translation unit wherestd::unique_lock/std::shared_lockare used.
std::unique_lock<std::shared_mutex> lock(mutex_);
src/db/collection.cc:247
- The PR description mentions adding an
optimize_mtx_to serialize concurrentOptimize()calls, but the implementation introducesmaintenance_mtx_that serializes a broader set of operations (Optimize + DDL + Flush + Close/Destroy). Either update the PR description to match the new behavior/lock, or adjust the code to align with the describedoptimize_mtx_scope if the broader serialization is unintended.
// Serializes maintenance operations (Optimize, schema DDL, Flush, Close
// and Destroy) without holding schema_handle_mtx_, so a maintenance
// operation waiting for a running Optimize never becomes a pending
// exclusive acquirer of the schema lock (which would block new readers).
// Lock order: maintenance -> schema -> write -> SegmentManager; never
// acquire maintenance_mtx_ after any of the others.
mutable std::mutex maintenance_mtx_;
…currency UT Per review feedback: - Flush() only flushes the writing segment's WAL (no schema change, no segment switch, no version update), so it no longer takes maintenance_mtx_ - Guard opened_segments[opened_index++] with a bounds check in the Optimize commit phase. - Cap the fetcher/querier worker loops at 100k iterations so a hung Optimize cannot hang the test forever. - Assert Stats() succeeded before reading its value in the concurrent regression test.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
tests/db/collection_test.cc:2944
- This fixed sleep makes the concurrency regression test timing-dependent and potentially flaky (Optimize may still be in phase 1 or may already complete on fast/slow machines). Prefer a deterministic sync point (e.g., a barrier/latch exposed via test hook/observer), or at minimum assert Optimize is still running after the delay and
GTEST_SKIP()(or adjust workload) if it already finished.
// let the optimizer reach the compact phase
std::this_thread::sleep_for(std::chrono::milliseconds(200));
tests/db/collection_test.cc:3068
- The way
ops_during_optimizeis counted (if (!optimize_done.load())after each operation) can undercount operations that overlapped with Optimize but finished right asoptimize_doneflips, causing intermittent failures. Consider counting using a pre/post sample (e.g., read a localwas_optimizing = !optimize_done.load()before the op) or use a separate 'entered_compact_phase' signal to measure progress during the intended window.
// if any had been blocked for the whole Optimize, no ops could have
// completed while Optimize was still running
ASSERT_GE(writer_result.ops_during_optimize, 2);
ASSERT_GE(fetch_result.ops_during_optimize, 2);
ASSERT_GE(query_result.ops_during_optimize, 2);
tests/db/collection_test.cc:3123
- This directly calls
.value()on theStats()result without checkinghas_value(), which will crash the test on error instead of producing an assertion failure with context. AddASSERT_TRUE(collection->Stats().has_value())(or store the result and assert) before accessing.value().
auto stats = collection->Stats().value();
src/db/index/segment/segment_manager.cc:58
- Holding the
SegmentManagerexclusive lock while callingsegment->destroy()can block all concurrent segment readers for the duration of potentially slow I/O, and increases the risk of lock coupling issues ifdestroy()calls back into code that needsSegmentManager. A safer pattern is to remove (or move-out) theSegment::Ptrunder lock, release the lock, then performdestroy()without holdingmutex_.
std::unique_lock<std::shared_mutex> lock(mutex_);
auto iter = segments_map_.find(segment_id);
if (iter == segments_map_.end()) {
return Status::NotFound("Segment not found");
}
auto s = iter->second->destroy();
CHECK_RETURN_STATUS(s);
segments_map_.erase(iter);
return Status::OK();
src/db/collection.cc:864
- If
MoveDirectory(...)succeeds butSegment::Open(...)fails, Optimize returns early leaving an orphaned moved segment directory on disk that is not referenced by the manifest/version. Consider adding best-effort cleanup on failure (e.g., delete the moved directory or move it back / move it to a quarantine tmp path) to prevent disk leaks and confusing leftovers.
if (!FileHelper::MoveDirectory(tmp_segment_path, new_segment_path)) {
return Status::InternalError("move segment directory failed");
}
compact_task.output_segment_meta_->set_id(new_segment_id);
auto new_segment =
Segment::Open(path_, *schema_, *compact_task.output_segment_meta_,
id_map_, delete_store_, version_manager_,
SegmentOptions{true, options_.enable_mmap_});
if (!new_segment.has_value()) {
return new_segment.error();
}
… layout Per Copilot review: - Count worker ops by sampling optimize_done before the operation, so ops that overlap the Optimize completion boundary are not lost. - Walk all segment directories recursively when counting superseded index files, instead of assuming the persisted segment lives in /0. - Assert Stats() has_value() in the superseded-file test. - Remove the moved-but-unopened segment directory on Segment::Open failure (best-effort cleanup, manifest not yet persisted).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/db/index/segment/segment_manager.cc:144
- The async lambda in this loop uses
[&]capture, but it indirectly captures the loop-localsegmentreference. Because that reference is reassigned each iteration, async tasks can end up operating on the wrong segment. Capture theSegment::Ptrby value in the lambda.
for (size_t i = 0; i < segments.size(); i += concurrency) {
size_t end = std::min(i + concurrency, segments.size());
src/db/collection.cc:867
- If MoveDirectory() or Segment::Open() fails, only the current
new_segment_pathis cleaned up. Any segments already moved/opened earlier in this loop will be left orphaned on disk (not referenced by any manifest), which can accumulate garbage in the collection directory. Consider best-effort cleanup of all previously moved segments on these early-return paths.
if (!FileHelper::MoveDirectory(tmp_segment_path, new_segment_path)) {
return Status::InternalError("move segment directory failed");
}
compact_task.output_segment_meta_->set_id(new_segment_id);
…n up all moved dirs Per Copilot review: - In SegmentManager::add_column/alter_column, the std::async lambda captured the loop-local 'segment' reference by reference; since it is rebinding each iteration, async tasks could operate on the wrong segment. Capture it by value instead (pre-existing in main, fixed while touching these functions). - In Optimize's lock-free rename/open loop, track all moved segment directories and remove them (best-effort) on any failure, so earlier successfully-moved dirs do not accumulate as orphans.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/db/index/segment/segment_manager.cc:33
std::unique_lock<std::shared_mutex>is used in this file, but the translation unit doesn't include<mutex>(it currently only includes<algorithm>,<future>,<thread>, etc.). Relying on transitive includes is fragile and can break builds on some standard library implementations; include<mutex>explicitly.
Status SegmentManager::add_segment(Segment::Ptr segment) {
if (!segment) {
return Status::InvalidArgument("Segment is null");
}
std::unique_lock<std::shared_mutex> lock(mutex_);
segments_map_[segment->id()] = segment;
return Status::OK();
Extend the reviewer's shared_ptr-reduction feedback to the remaining hot paths: - ConvertArrowRowToDocField now takes a raw Array* (callers pass batch.columns()[i].get() / chunk.get()), removing per-row atomic ref-count traffic. - DocIterator PK/doc_id/row-id extraction uses type_id() + static_cast instead of per-row dynamic_pointer_cast. - Update the Scan comment after alibaba#614: snapshot consistency comes from the write_mtx_ atomic snapshot + shared_ptr keep-alive, not from blocking Optimize (which now takes the schema lock in shared mode).
Summary
Optimize()heldschema_handle_mtx_exclusively for its whole duration, blocking Insert/Query/Fetch (shared mode) until the long compact finished. This PR makes the compact phase lock-free.Changes
maintenance_mtx_(new): serializes Optimize, schema DDLs, Close and Destroy before the schema lock. Waiters no longer become pending exclusive acquirers of the schema lock, so readers are never stalled by a queued maintenance operation.MoveDirectory+Segment::Openrun at the lock-free phase tail, so an open failure aborts before the manifest is persisted.shared_mutex(map was previously unsynchronized; reads and writes now race safely).Tests
Fixes #553